/*
 * MIT License
 *
 * Copyright (c) 2025 milkpotatoes
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

export enum TestLevel {
    L0 = 1 << 0,
    L1 = 1 << 1,
    L2 = 1 << 2,
    L3 = 1 << 3,
    L4 = 1 << 4,
}

declare interface TestcaseDescriptor {
    name: string;
    desc: string;
    level: TestLevel;
    test: TestFunction;
}

declare type TestFunction = () => void | Promise<void>;

class FailReason {
    reason: any;
    constructor(reason: any) {
        this.reason = reason;
    }
}

let g_testRepoter: RunnerTestRepoter = console;
let g_testCaseAssertionCount = 0;
const runWithoutError = async (func: TestFunction, name: string, checkAssertion: boolean = false): Promise<undefined | FailReason> => {
    g_testCaseAssertionCount = 0;
    try {
        await func();
    } catch (e) {
        if (e instanceof Error)
            g_testRepoter.error(`\x1b[31m[FAIL]\x1b[0m ${name}, Reason:\n${e.message}\n${e.stack}`);
        else
            g_testRepoter.error(`\x1b[31m[FAIL]\x1b[0m ${name}, Reason:\n${e}`);
        return new FailReason(e);
    }
    if (checkAssertion && g_testCaseAssertionCount === 0) {
        g_testRepoter.warn(`\x1b[33m[WARN]\x1b[0m ${name}: No assertion in test case.`);
    }
    return undefined;
}

const runTestcase = async (suit: string, befores: TestFunction[], afters: TestFunction[], test: TestcaseDescriptor): Promise<undefined | FailReason> => {
    const { name, desc, level, test: func } = test;
    const logName = `${suit}.${TestLevel[level]}.${name}`;
    let failReason = undefined;

    for (const cb of befores) {
        await runWithoutError(cb, `${logName} beforeEach failed`);
    }
    g_testRepoter.info(`[ RUN] ${logName}: ${desc}`);
    failReason = await runWithoutError(async () => {
        const start = new Date();
        await func();
        g_testRepoter.info(`\x1b[32m[SUCC]\x1b[0m [${(new Date().getTime() - start.getTime())}ms] ${logName}`);
    }, logName, true);
    for (const cb of afters) {
        await runWithoutError(cb, `${logName} afterEach failed`);
    }
    return failReason;
}

declare interface RunnerTestRepoter {
    info(msg: string): void
    warn(msg: string): void
    error(msg: string): void
}

export class RunnerTest {
    #suitName: string;
    #before: TestFunction[] = [];
    #after: TestFunction[] = [];
    #beforeEach: TestFunction[] = [];
    #afterEach: TestFunction[] = [];
    #tesecaseContainer: TestcaseDescriptor[] = [];
    #testcaseNameSet: Set<string> = new Set();
    #testRepoter: RunnerTestRepoter = console;
    constructor(name: string) {
        this.#suitName = name;
    }
    before(func: TestFunction) {
        this.#before.push(func);
    }
    beforeEach(func: TestFunction) {
        this.#beforeEach.push(func);
    }
    after(func: TestFunction) {
        this.#after.push(func);
    }
    afterEach(func: TestFunction) {
        this.#afterEach.push(func);
    }

    add(name: string, description: string, level: TestLevel, test: TestFunction): void {
        if (this.#testcaseNameSet.has(name))
            throw RangeError(`test case name '${name}' already exist in current test case suit.`);
        this.#testcaseNameSet.add(name);
        this.#tesecaseContainer.push({ name: name, desc: description, level: level, test: test });
    }
    setReporter(repoter: RunnerTestRepoter): RunnerTest {
        this.#testRepoter = repoter;
        return this;
    }
    async run(nameOrLevel?: TestLevel | string | string[]): Promise<number> {
        let oldRepoter = g_testRepoter;
        g_testRepoter = this.#testRepoter;
        const start = new Date();
        const fails: { reason: any, tc: TestcaseDescriptor }[] = [];
        const tests = nameOrLevel === undefined ? this.#tesecaseContainer : this.#tesecaseContainer.filter(tc => {
            if (typeof nameOrLevel === 'number') {
                return tc.level & nameOrLevel;
            } else if (typeof nameOrLevel === 'string') {
                return tc.name === nameOrLevel;
            } else if (Array.isArray(nameOrLevel)) {
                return nameOrLevel.includes(tc.name);
            }
            return true;
        });
        for (const cb of this.#before) {
            await runWithoutError(cb, `${this.#suitName} before failed`);
        }
        for (const tc of tests) {
            const result = await runTestcase(this.#suitName, this.#beforeEach, this.#afterEach, tc);
            if (result !== undefined) {
                fails.push({ reason: result, tc: tc });
            }
        }
        for (const cb of this.#after) {
            await runWithoutError(cb, `${this.#suitName} after failed`);
        }
        g_testRepoter.info(`${tests.length} testcase ran, total ${new Date().getTime() - start.getTime()}ms costs`);
        if (fails.length > 0) {
            g_testRepoter.error(`${fails.length} fails from ${tests.length} testcases:`);
            for (const fail of fails) {
                g_testRepoter.error(`  - ${fail.tc.name}`);
            }
            g_testRepoter.error('end of tests');
        }
        g_testRepoter = oldRepoter;
        return fails.length;
    }
}

export class AssertionError extends Error {
    name: string = 'AssertionError';
}
const reportAndMakeError = (msg: string) => {
    g_testRepoter.error(msg);
    return new AssertionError(msg);
}
const toStrictEqual = (a: any, b: any): boolean => {
    const typeA = typeof a;
    const typeB = typeof b;
    if (typeA !== typeB) return false;
    if (typeA === 'object' && a !== null && b !== null) {
        const propA = Object.getOwnPropertyNames(a);
        const propB = Object.getOwnPropertyNames(b);
        if (propA.length !== propB.length) return false;
        for (const p of propA) {
            if (!toStrictEqual(a[p], b[p])) return false;
        }
        return true;
    }
    return a === b;
}
type ValidJSTypeName = 'bigint' | 'symbol' | 'function' | 'undefined' | 'object' | 'boolean' | 'number' | 'string';
export class RunnerAssertUtil {
    #actualValue: any;
    constructor(actual: any) {
        this.#actualValue = actual;
    }
    get not(): RunnerAssertUtil {
        return new RunnerAssertUtil(!this.#actualValue);
    }
    assertDeepEq(expected: any): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (typeof expected !== 'object' || expected === null) {
            if (expected !== this.#actualValue)
                throw reportAndMakeError(`[ASSERT] expect deep equal object, but got ${typeof expected}`);
        }
        if (!toStrictEqual(expected, this.#actualValue)) {
            throw reportAndMakeError(`[ASSERT] expect deep equal object, but got different object`);
        }
        return this;
    }
    assertEq(expected: any): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (expected !== this.#actualValue)
            throw reportAndMakeError(`[ASSERT] expect ${expected}, but actual ${this.#actualValue}`);
        return this;
    }
    assertNe(expected: any): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (expected === this.#actualValue)
            throw reportAndMakeError(`[ASSERT] expect not ${expected}, but actual ${this.#actualValue}`);
        return this;
    }
    assertGe(expected: any): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue < expected)
            throw reportAndMakeError(`[ASSERT] expect >= ${expected}, but actual ${this.#actualValue}`);
        return this;
    }
    assertGt(expected: any): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue <= expected)
            throw reportAndMakeError(`[ASSERT] expect > ${expected}, but actual ${this.#actualValue}`);
        return this;
    }
    assertLe(expected: any): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue > expected)
            throw reportAndMakeError(`[ASSERT] expect <= ${expected}, but actual ${this.#actualValue}`);
        return this;
    }
    assertLt(expected: any): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue >= expected)
            throw reportAndMakeError(`[ASSERT] expect < ${expected}, but actual ${this.#actualValue}`);
        return this;
    }
    assertTrue(): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue !== true)
            throw reportAndMakeError(`[ASSERT] expect true, but got ${this.#actualValue}`);
        return this;
    }
    assertFalse(): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue !== false)
            throw reportAndMakeError(`[ASSERT] expect false, but got ${this.#actualValue}`);
        return this;
    }
    assertUndefined(): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue !== undefined)
            throw reportAndMakeError(`[ASSERT] expect undefined, but got ${this.#actualValue}`);
        return this;
    }
    assertNull(): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue !== null)
            throw reportAndMakeError(`[ASSERT] expect null, but got ${this.#actualValue}`);
        return this;
    }
    assertNonNull(): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (this.#actualValue === null || this.#actualValue === undefined) {
            const failMessage = `[ASSERT] expect non-null, but got ${this.#actualValue}`;
            g_testRepoter.error(failMessage)
            throw new AssertionError(failMessage);
        }
        return this;
    }
    assertInstanceOf(constructor: Function): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (typeof constructor !== 'function') {
            throw reportAndMakeError(`[ASSERT] expect constructor to be a function, but got ${typeof constructor}`);
        }
        if (!(this.#actualValue instanceof constructor))
            throw reportAndMakeError(`[ASSERT] expect instanceof ${constructor.name}, ` +
                `but actual ${this.#actualValue?.constructor?.name ?? typeof this.#actualValue}`);
        return this;
    }
    assertType(type: ValidJSTypeName): RunnerAssertUtil {
        g_testCaseAssertionCount++;
        if (typeof this.#actualValue !== type)
            throw reportAndMakeError(`[ASSERT] expect type ${type}, but actual ${typeof this.#actualValue}`);
        return this;
    }
    async assertNoThrow(): Promise<RunnerAssertUtil> {
        g_testCaseAssertionCount++;
        if (this.#actualValue instanceof Function) {
            try {
                await this.#actualValue();
            } catch (e) {
                if (e instanceof Error)
                    throw reportAndMakeError(`[ASSERT] expect no throw, but got error\n${e.name}: ${e.message}\n${e.stack}`);
                else
                    throw reportAndMakeError(`[ASSERT] expect no throw, but got\n${e}.`);
            }
            return this;
        }
        throw reportAndMakeError(`[ASSERT] expect a function to assert no throw, but got ${typeof this.#actualValue}`);
    }
    async assertThrow(check?: (e: any) => void | boolean | Promise<void | boolean>): Promise<RunnerAssertUtil> {
        g_testCaseAssertionCount++;
        if (this.#actualValue instanceof Function) {
            try {
                await this.#actualValue();
            } catch (e) {
                if (check == undefined) return this;
                if (await check(e) === false) {
                    throw reportAndMakeError('[ASSERT] Failed to assert, check result is false');
                }
                return this;
            }
            throw reportAndMakeError('[ASSERT] Failed to assert, throw expected but no error caught after test.');
        }
        throw reportAndMakeError(`[ASSERT] expect a function to assert throw, but got ${typeof this.#actualValue}`);
    }
    async assertError(err: Error): Promise<RunnerAssertUtil> {
        g_testCaseAssertionCount++;
        if (this.#actualValue instanceof Function) {
            await this.assertThrow(async (e) => {
                if (!(e instanceof Error))
                    throw reportAndMakeError('[ASSERT] Failed to assert, Error expected.');
                const caught = e as Error;
                expect(err.name).assertEq(caught.name);
                expect(err.message).assertEq(caught.message);
            });
            return this;
        }
        throw reportAndMakeError(`[ASSERT] expect a function to assert error, but got ${typeof this.#actualValue}`);
    }
}

export function expect(actual: any): RunnerAssertUtil {
    return new RunnerAssertUtil(actual);
}

let g_suit: RunnerTest | null = null;
export function describe(name: string, func: TestFunction) {
    const suit = new RunnerTest(name);
    const oldSuit = g_suit;
    g_suit = suit;
    func();
    g_suit = oldSuit;
    return suit;
}
export function it(name: string, description: string, level: TestLevel, test: TestFunction) { g_suit!.add(name, description, level, test); }
export function before(func: TestFunction) { g_suit!.before(func); }
export function beforeEach(func: TestFunction) { g_suit!.beforeEach(func); }
export function after(func: TestFunction) { g_suit!.after(func); }
export function afterEach(func: TestFunction) { g_suit!.afterEach(func); }
